Error Handling
Core rule:
Async Rust uses the same error handling model as sync Rust.
That means:
Result<T, E>?operator- Custom error types
From/Into
Async does not add exceptions, promises, or magic propagation.
The only difference:
Errors travel through futures.
Async functions and Result
An async function returning a result:
async fn fetch_data() -> Result<String, std::io::Error> {
Ok("data".to_string())
}
This actually returns:
impl Future<Output = Result<String, std::io::Error>>
So .await gives you a Result.
Using ? inside async functions
Nothing special here.
use tokio::fs;
async fn read_file() -> Result<String, std::io::Error> {
let content = fs::read_to_string("data.txt").await?;
Ok(content)
}
- If
read_to_stringfails - The error is returned immediately
- The future resolves to
Err(...)
Handling errors at .await sites
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
let content = read_file().await?;
println!("{content}");
Ok(())
}
Clean. Linear. No nesting.
Error handling across .await boundaries
Each .await is a potential suspension point.
But:
- Errors do not get lost
- Stack traces remain logical (not real call stacks)
async fn step1() -> Result<(), &'static str> {
Err("step1 failed")
}
async fn step2() -> Result<(), &'static str> {
step1().await?;
Ok(())
}
If step1 fails:
step2stops immediately- Error propagates normally
Custom error types in async code
Same as sync Rust.
Using thiserror
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid input")]
InvalidInput,
}
Async function using it
use tokio::fs;
async fn load_config() -> Result<String, AppError> {
let content = fs::read_to_string("config.toml").await?;
if content.is_empty() {
return Err(AppError::InvalidInput);
}
Ok(content)
}
Error handling with spawned tasks (very important)
The JoinHandle problem
let handle = tokio::spawn(async {
do_work().await
});
The type is:
JoinHandle<Result<T, E>>
So you get two layers of error.
async fn do_work() -> Result<(), &'static str> {
Err("work failed")
}
#[tokio::main]
async fn main() {
let handle = tokio::spawn(do_work());
match handle.await {
Ok(Ok(())) => println!("success"),
Ok(Err(e)) => println!("task error: {e}"),
Err(e) => println!("task panicked: {e}"),
}
}
Err(JoinError)→ task panicked or was cancelledErr(E)→ task ran but returned error
Cancelling tasks and errors
Dropping a task handle cancels the task.
let handle = tokio::spawn(async {
loop {
println!("working...");
tokio::time::sleep(Duration::from_secs(1)).await;
}
});
drop(handle); // task is cancelled
- No error is returned
- Task simply stops
- Use
select! if you need cleanup
Error handling with select!
use tokio::select;
async fn might_fail() -> Result<(), &'static str> {
Err("oops")
}
#[tokio::main]
async fn main() {
let result = select! {
res = might_fail() => res,
_ = tokio::time::sleep(Duration::from_secs(1)) => Ok(()),
};
if let Err(e) = result {
println!("error: {e}");
}
}
Error propagation works naturally.
Streams and error handling
Stream<Item = Result<T, E>> pattern
Very common.
use futures::StreamExt;
while let Some(item) = stream.next().await {
match item {
Ok(value) => println!("value: {value}"),
Err(e) => {
println!("error: {e}");
break;
}
}
}
Alternative: fail-fast
let values: Result<Vec<_>, _> = stream.collect().await;
Timeouts and errors
Timeouts convert slow futures into errors.
use tokio::time::{timeout, Duration};
let result = timeout(Duration::from_secs(1), async {
do_work().await
}).await;
match result {
Ok(Ok(())) => println!("success"),
Ok(Err(e)) => println!("task error: {e}"),
Err(_) => println!("timed out"),
}
Panic vs Result in async
Panics
- Kill the task
- Do not kill the runtime
- Propagate as
JoinError
Prefer Result for:
- IO failures
- Business logic errors
- Recoverable conditions
Common async error-handling mistakes
- Ignoring
JoinHandle
tokio::spawn(do_work()); // error silently dropped
- Using
unwrap()in async tasks: Panics get harder to trace. - Mixing error types across layers: Use
Fromconversions or error enums.
Mental model
Think of async error handling as:
“Same rules, delayed delivery.”
Errors:
- Are values
- Travel through futures
- Surface at
.await